이 게시물은 한국어를 지원하지 않습니다.
Overview
Spawning objects can be performance-intensive, especially when spawning a large number of them. To address this, we can implement an object pool—in Unreal Engine, an Actor Pool is more appropriate. The idea is to pre-spawn actors during loading, then deactivate and hide them. When we need to spawn one, we retrieve it from the pool and activate it. When we're done with it, we return it to the pool instead of destroying it, so it can be reused.
There are many object pool or actor pool plugins available on Fab, but actor pooling is actually quite easy to implement. So, I decided to create one myself.
My version of the actor pool supports multiplayer and runs only on the server side because clients typically do not spawn replicated gameplay actors directly.
📚 Further Reading on Object Pooling
- 🎮 Object Pool とは(ランカース開発ブログ)
- 📘 Game Programming Patterns – Object Pool
- 📖 Wikipedia – Object Pool Pattern
Environment
- Unreal Engine 5.6.0
- Windows 11 Pro
Main Content
Normal Spawning Performance
Before the implementation, I measured the performance of spawning my projectile so I can compare it later.
Max Time of ServerSpawnProjectile (363.3 µs)
As we can see, within SpawnActor (359.2 µs)
, the engine processes ConstructObject (78.8 µs)
and RegisterAllComponents (122.9 µs)
, which can be avoided by using an actor pool. Only BeginPlay
is needed for the custom logic.
💡 By the way, the reason for the
Blueprint Time (362.4 - 359.2 = 3.2 µs)
cost is thatServerSpawnProjectile
is marked as aUFUNCTION()
, so the engine launches the Blueprint virtual machine to process it, even though it's a C++ function. This is unnecessary overhead (engine issue).
As we can see, when an actor is spawned, a lot of initialization takes place, which impacts performance.
This is the flowchart of spawning actor and despawn actor in actor pool.
Create an Actor Pool in C++
Create an IPoolableInterface
for Poolable Actors
We create an interface that will be called when an actor is activated from or deactivated to the pool, allowing us to implement custom logic. Since pooled actors do not trigger BeginPlay
or EndPlay
, we need to use this interface to manage lifecycle behavior manually.
title=YourProject/Core/Interface/IPoolableInterface.h1#pragma once 2 3#include "CoreMinimal.h" 4#include "UObject/Interface.h" 5#include "IPoolableInterface.generated.h" 6 7class UActorPool; 8 9UINTERFACE(MinimalAPI) 10class UPoolableInterface : public UInterface 11{ 12 GENERATED_BODY() 13}; 14 15/** 16 * Interface for actors that can be managed by the actor pool system. 17 * Provides callbacks for when actors are activated from or returned to the pool. 18 */ 19class YOUR_API IPoolableInterface 20{ 21 GENERATED_BODY() 22 23public: 24 /** 25 * Called when an actor is retrieved from the pool and activated for gameplay. 26 * Use this to start timers, initialize state, and prepare for active use. 27 * @param InActorPool The pool this actor belongs to 28 * @param Location The world location to spawn at 29 * @param Rotation The world rotation to spawn with 30 * @param SpawnParameters Additional spawn parameters 31 */ 32 virtual void OnActivateFromPool(UActorPool* InActorPool, const FVector& Location, const FRotator& Rotation, const FActorSpawnParameters& SpawnParameters) = 0; 33 34 /** 35 * Called when an actor is being returned to the pool and deactivated. 36 * Use this to clear timers, reset state, and prepare for pool storage. 37 */ 38 virtual void OnDeactivateFromPool() = 0; 39};
Create the ActorPool class
We use a UObject
to implement the pool. While many developers and plugins use centralized systems like managers, subsystems, or ActorComponent
(which can only be attached to Actor
), I prefer using a UObject
for its flexibility. A UObject
-based pool can be owned and managed by any class, such as an actor, game mode, subsystem, or component. It is easy to integrate where needed. This decentralized approach keeps the design simple, easier to debug, and more configurable (e.g., setting prewarm counts). It’s also more decoupled, promotes reuse, and comes with less overhead compared to actor-based components or global subsystems.
Header file:
YourProject/Core/Utility/Object/ActorPool.h1 2#pragma once 3 4#include "CoreMinimal.h" 5#include "Engine/World.h" 6#include "UObject/Object.h" 7#include "ActorPool.generated.h" 8 9/** 10 * 11 */ 12UCLASS() 13class YOUR_API UActorPool : public UObject 14{ 15 GENERATED_BODY() 16 17public: 18 UFUNCTION(BlueprintCallable, Category = "Actor Pool") 19 void InitializePool(TSubclassOf<AActor> InActorClass, int32 InPrewarmCount = 5); 20 21 UFUNCTION(BlueprintCallable, Category = "Actor Pool") 22 void ReturnToPool(AActor* Actor); 23 24public: 25 AActor* TrySpawnPooledActor(const FVector& Location, const FRotator& Rotation, 26 const FActorSpawnParameters& SpawnParameters = FActorSpawnParameters()); 27 28 FORCEINLINE bool IsEmpty() const 29 { 30 return PooledActors.Num() == 0; 31 } 32 33 FORCEINLINE int32 GetSize() const 34 { 35 return PooledActors.Num(); 36 } 37 38 FORCEINLINE void PushActor(AActor* Actor); 39 40 AActor* PopActor(); 41 42protected: 43 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Actor Pool") 44 TSubclassOf<AActor> ActorClass; 45 46 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Actor Pool", meta = (ClampMin = "1")) 47 int32 PrewarmCount = 5; 48 49 UPROPERTY() 50 TArray<TObjectPtr<AActor>> PooledActors; 51 52private: 53 void PrewarmPool(); 54 void ActivatePooledActor(AActor* Actor, const FVector& Location, const FRotator& Rotation, 55 const FActorSpawnParameters& SpawnParameters); 56 void DeactivatePooledActor(AActor* Actor); 57 58private: 59 // Stats 60 int32 PoolMisses = 0; 61}; 62
💡 Important: Make sure to add UPROPERTY() to PooledActors. Otherwise, Unreal's garbage collector may remove the actors unexpectedly, causing hard-to-debug issues 😨.
cpp file:
YourProject/Core/Utility/Object/ActorPool.cpp1 2 3#include "ActorPool.h" 4 5#include <YourProject/Core/Interface/IPoolableInterface.h> 6 7DEFINE_LOG_CATEGORY_STATIC(LogActorPool, Log, All); 8 9void UActorPool::InitializePool(TSubclassOf<AActor> InActorClass, int32 InPrewarmCount) 10{ 11 if (!IsValid(InActorClass)) 12 { 13 UE_LOG(LogActorPool, Error, TEXT("UActorPool::InitializePool - Invalid Actor Class")); 14 return; 15 } 16 17 ActorClass = InActorClass; 18 PrewarmCount = InPrewarmCount; 19 PooledActors.Empty(); 20 PrewarmPool(); 21} 22 23void UActorPool::PrewarmPool() 24{ 25 if (!IsValid(ActorClass) || PrewarmCount <= 0) 26 { 27 UE_LOG(LogActorPool, Warning, TEXT("UActorPool::PrewarmPool - Invalid Actor Class or Prewarm Count")); 28 return; 29 } 30 31 UWorld* World = GetWorld(); 32 if (!IsValid(World)) 33 { 34 UE_LOG(LogActorPool, Error, TEXT("UActorPool::PrewarmPool - Invalid World")); 35 return; 36 } 37 for (int32 i = 0; i < PrewarmCount; ++i) 38 { 39 FActorSpawnParameters SpawnParams; 40 SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; 41 42 AActor* NewActor = World->SpawnActor<AActor>(ActorClass, FVector::ZeroVector, FRotator::ZeroRotator, 43 SpawnParams); 44 if (IsValid(NewActor)) 45 { 46 DeactivatePooledActor(NewActor); 47 PushActor(NewActor); 48 UE_LOG(LogActorPool, Log, TEXT("Prewarmed actor %s (%d/%d)"), 49 *NewActor->GetName(), i + 1, PrewarmCount); 50 } 51 else 52 { 53 UE_LOG(LogActorPool, Error, TEXT("UActorPool::PrewarmPool - Failed to spawn actor %s"), 54 *ActorClass->GetName()); 55 } 56 } 57} 58 59AActor* UActorPool::TrySpawnPooledActor(const FVector& Location, const FRotator& Rotation, 60 const FActorSpawnParameters& SpawnParameters) 61{ 62 if (!IsValid(ActorClass) || PrewarmCount <= 0) 63 { 64 UE_LOG(LogActorPool, Warning, 65 TEXT("UActorPool::TrySpawnPooledActor - Invalid Actor Class or Prewarm Count")); 66 return nullptr; 67 } 68 69 70 UWorld* World = GetWorld(); 71 if (!IsValid(World)) 72 { 73 UE_LOG(LogActorPool, Warning, TEXT("TrySpawnPooledActor: Invalid World")); 74 return nullptr; 75 } 76 77 if (AActor* PooledActor = PopActor()) 78 { 79 ActivatePooledActor(PooledActor, Location, Rotation, SpawnParameters); 80 UE_LOG(LogActorPool, Log, TEXT("Spawned pooled actor: %s at location: %s, rotation: %s"), 81 *PooledActor->GetName(), *Location.ToString(), *Rotation.ToString()); 82 return PooledActor; 83 } 84 85 PoolMisses++; 86 UE_LOG(LogActorPool, Warning, 87 TEXT("Pool empty for class: %s, falling back to spawn new actor. Pool Misses: %d, Prewarm Count: %d"), 88 *ActorClass->GetName(), PoolMisses, PrewarmCount); 89 90 AActor* NewActor = World->SpawnActor<AActor>(ActorClass, Location, Rotation, SpawnParameters); 91 if (IsValid(NewActor)) 92 { 93 ActivatePooledActor(NewActor, Location, Rotation, SpawnParameters); 94 } 95 return NewActor; 96} 97 98void UActorPool::ReturnToPool(AActor* Actor) 99{ 100 if (!IsValid(Actor)) 101 { 102 return; 103 } 104 105 // Check authority for network safety 106 if (Actor->GetLocalRole() != ROLE_Authority) 107 { 108 UE_LOG(LogActorPool, Error, TEXT("Not Authority, cannot return actor to pool: %s"), *Actor->GetName()); 109 return; 110 } 111 112 DeactivatePooledActor(Actor); 113 PushActor(Actor); 114} 115 116void UActorPool::ActivatePooledActor(AActor* Actor, const FVector& Location, const FRotator& Rotation, 117 const FActorSpawnParameters& SpawnParameters) 118{ 119 if (!IsValid(Actor)) 120 { 121 return; 122 } 123 124 // Cache root component lookup to avoid repeated virtual calls 125 UPrimitiveComponent* RootPrimitive = Cast<UPrimitiveComponent>(Actor->GetRootComponent()); 126 127 // Batch actor transform and ownership changes 128 Actor->SetActorLocationAndRotation(Location, Rotation); 129 Actor->SetOwner(SpawnParameters.Owner); 130 Actor->SetInstigator(SpawnParameters.Instigator); 131 132 // Batch actor state changes 133 Actor->SetActorHiddenInGame(false); 134 Actor->SetActorEnableCollision(true); 135 Actor->SetActorTickEnabled(true); 136 137 // Reset physics state if primitive component exists 138 if (RootPrimitive) 139 { 140 RootPrimitive->SetAllPhysicsLinearVelocity(FVector::ZeroVector); 141 RootPrimitive->SetAllPhysicsAngularVelocityInDegrees(FVector::ZeroVector); 142 } 143 144 Actor->Reset(); 145 146 // Call poolable interface if implemented 147 if (IPoolableInterface* PoolableInterface = Cast<IPoolableInterface>(Actor)) 148 { 149 PoolableInterface->OnActivateFromPool(this, Location, Rotation, SpawnParameters); 150 } 151} 152 153void UActorPool::DeactivatePooledActor(AActor* Actor) 154{ 155 // Call poolable interface first to allow cleanup before state changes 156 if (IPoolableInterface* PoolableInterface = Cast<IPoolableInterface>(Actor)) 157 { 158 PoolableInterface->OnDeactivateFromPool(); 159 } 160 161 // Cache root component lookup to avoid repeated virtual calls 162 UPrimitiveComponent* RootPrimitive = Cast<UPrimitiveComponent>(Actor->GetRootComponent()); 163 164 // Batch actor state changes 165 Actor->SetActorHiddenInGame(true); 166 Actor->SetActorEnableCollision(false); 167 Actor->SetActorTickEnabled(false); 168 169 // Reset physics state if primitive component exists 170 if (RootPrimitive) 171 { 172 RootPrimitive->SetAllPhysicsLinearVelocity(FVector::ZeroVector); 173 RootPrimitive->SetAllPhysicsAngularVelocityInDegrees(FVector::ZeroVector); 174 } 175 176 // Clear ownership references 177 Actor->SetOwner(nullptr); 178 Actor->SetInstigator(nullptr); 179} 180 181void UActorPool::PushActor(AActor* Actor) 182{ 183 PooledActors.Add(Actor); 184} 185 186AActor* UActorPool::PopActor() 187{ 188 while (IsEmpty() == false) 189 { 190 if (AActor* Actor = PooledActors.Pop(); IsValid(Actor)) 191 { 192 return Actor; 193 } 194 } 195 return nullptr; 196}
if there are not enough pooled actors, the pool fallback to normal spawning, and dynamically expands the pool. It also counts the pool misses and log the warning so we can adjust the prewarm pool size accordingly.
Example Usage
Implement this interface in your poolable actor and add your custom logic to OnActivateFromPool()
and OnDeactivateFromPool()
.
For example, I have a projectile class that holds a pointer to its ActorPool
, allowing it to return itself to the pool instead of being destroyed.
Header:
ProjectileBase.h1 2// ... 3 4public: 5// IPoolableInterface 6 virtual void OnActivateFromPool(UActorPool* InActorPool, const FVector& Location, const FRotator& Rotation, const FActorSpawnParameters& SpawnParameters) override; 7 virtual void OnDeactivateFromPool() override; 8 9 void ReturnToPoolOrDestroy(); 10 11protected: 12UPROPERTY() 13 TObjectPtr<UActorPool> ActorPool;
ProjectileBase.cpp1 2void AProjectileBase::OnActivateFromPool(UActorPool* InActorPool, const FVector& Location, const FRotator& Rotation, 3 const FActorSpawnParameters& SpawnParameters) 4{ 5 ActorPool = InActorPool; 6 7 // Recalculate projectile velocity based on rotation 8 if (UProjectileMovementComponent* MovementComp = GetProjectileMovement()) 9 { 10 // Ensure the movement component has the correct UpdatedComponent 11 if (CollisionComp) 12 { 13 MovementComp->SetUpdatedComponent(CollisionComp); 14 } 15 16 // Calculate velocity based on spawn rotation, not actor forward (which might be wrong for pooled actors) 17 FVector InitialVelocity = Rotation.Vector() * MovementComp->InitialSpeed; 18 19 MovementComp->StartSimulating(InitialVelocity); 20 } 21 else 22 { 23 UE_LOG(LogTemp, Error, TEXT("OnPoolActivate - No movement component found!")); 24 } 25} 26 27void AProjectileBase::OnDeactivateFromPool() 28{ 29 if (UProjectileMovementComponent* MovementComp = GetProjectileMovement()) 30 { 31 MovementComp->StopSimulating(FHitResult()); 32 33 MovementComp->UpdateComponentVelocity(); 34 } 35} 36 37 38void AProjectileBase::ReturnToPoolOrDestroy() 39{ 40 if (!HasAuthority()) 41 { 42 return; 43 } 44 45 if (!IsValid(ActorPool)) 46 { 47 UE_LOG(LogTemp, Warning, TEXT("AProjectileBase::ReturnToPoolOrDestroy - No ActorPool set! Destroying actor instead.")); 48 Destroy(); 49 return; 50 } 51 52 ActorPool->ReturnToPool(this); 53 54}
So I call ReturnToPoolOrDestroy()
instead of Destroy()
.
In the class that spawns the poolable actor, declare the ActorPool
in the header file. and store the poolable actor class for spawn.
YourClass.h1class UActorPool; 2 3//... 4{ 5 6//... 7 8protected: 9 10//... 11 UPROPERTY(EditDefaultsOnly, Category=Projectile) 12 TSubclassOf<class APawProjectileBase> ProjectileClass; 13 14 UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Actor Pool") 15 TObjectPtr<UActorPool> ProjectilePool; 16 17 UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Actor Pool", meta = (ClampMin = "1")) 18 int32 PrewarmCount = 3; 19}
Init the actor pool, give the class and prewarm count to the pool.
YourClass.cpp1 2// can be during your custom Game Loading 3// or just put in BeginPlay() 4ProjectilePool->InitializePool(ProjectileClass, PrewarmCount); 5// ...
Spawn the pooled actor
YourClass.cpp1// ... 2if (const UWorld* World = GetWorld(); IsValid(World)) 3 { 4 //Set Spawn Collision Handling Override 5 FActorSpawnParameters ActorSpawnParams; 6 7 // Specify the spawn params 8 9 // ActorSpawnParams.SpawnCollisionHandlingOverride = 10 // ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn; 11 // ActorSpawnParams.Owner = FPSPlayer; 12 // ActorSpawnParams.Instigator = FPSPlayer; 13 AActor* SpawnProjectile = ProjectilePool->TrySpawnPooledActor( 14 SpawnLocation, SpawnRotation, ActorSpawnParams); 15 } 16//...
Done!
This Actor Pool is also useful for pooling other gameplay objects such as AI enemies, pickups, whatever it is Actor.
Result
Before Actor Pool
Max Time of ServerSpawnProjectile (363.3 µs)
After Actor Pool
Max Time of ServerSpawnProjectile (187.2 µs)
The ConstructObject
and RegisterAllComponents
is avoided using Actor Pool, and I moved the BeginPlay
logic to OnActivateFromPool
and only that is needed.
Metric | Before (B4) | After (AFT) | Improvement |
---|---|---|---|
Min Time | 285.6 μs | 99.2 μs | ~2.88× speedup |
Max Time | 363.3 μs | 187.2 μs | ~1.94× speedup |
Before using the Actor Pool, spawning a projectile cost 285.6–363.3 μs.
After implementing the Actor Pool, activating a pooled projectile only takes 99.2–187.2 μs.
The heavy spawn cost is preloaded during the prewarm phase, resulting in an effective runtime speedup of ~1.94× to ~2.88×.
Conclusion
Using an Actor Pool
(Object Pool) in Unreal Engine 5 with C++ is a powerful way to reduce runtime overhead and improve performance, especially for frequently spawned and destroyed actors like projectiles, effects, or enemies. By implementing the pool with an UObject
, you gain flexibility, decoupling, and better reusability across your game architecture.
Whether you manage the pool from a game instance, game mode, subsystem, actor, or component, this decentralized approach is clean, easy to debug, and highly customizable.
If I'm wrong about anything, please feel free to correct me in the comments.